Skip to content

Extend documentation with a guide for plan resolvers (#244)#2985

Open
msotnikov wants to merge 2 commits intographile:mainfrom
msotnikov:plan-resolvers-recommendations
Open

Extend documentation with a guide for plan resolvers (#244)#2985
msotnikov wants to merge 2 commits intographile:mainfrom
msotnikov:plan-resolvers-recommendations

Conversation

@msotnikov
Copy link

Description

Addresses graphile/crystal-pre-merge#244 — general recommendations when writing plan resolvers.

Documentation:

  • Added a new "Best practices" page (grafast/website/grafast/plan-resolvers/best-practices.md) covering the four recommendations from the issue:
    1. Deep argument extraction — use getRaw(["path", "to", "value"]) or $-prefixed destructuring instead of shallow extraction followed by lambda transforms
    2. Prefer custom steps over lambda — comparison table, guidance on when lambda is appropriate vs when to create a step class, worked example of a custom step
    3. File-scoped lambda callbacks — why inline anonymous functions defeat deduplication, with do/don't examples
    4. Avoid try/catch in plan resolvers — why imperative error handling doesn't fit the declarative model, with examples using inhibitOnNull() and trap() instead
  • Added a "When to use something else" section to lambda.md pointing users toward loadOne/loadMany, custom steps, and sideEffect() before they reach for lambda
  • Added a tip callout linking to the best practices page from the top of plan-resolvers/index.mdx

Runtime warning:

  • In dev mode (NODE_ENV=development), lambda() now emits a console.warn when called with an anonymous (unnamed) callback function, since such callbacks cannot be deduplicated. The warning fires once per
    unique callback reference (tracked via WeakSet).

Performance impact

No impact in production. In development mode, a single WeakSet lookup is added per lambda() call (negligible).

Security impact

None.

Checklist

  • My code matches the project's code style and yarn lint:fix passes.
  • I've added tests for the new feature, and yarn test passes.
  • I have detailed the new feature in the relevant documentation.
  • I have added this feature to 'Pending' in the RELEASE_NOTES.md file (if one exists).
  • If this is a breaking change I've explained why.

@github-project-automation github-project-automation bot moved this to 🌳 Triage in V5.0.0 Mar 9, 2026
@changeset-bot
Copy link

changeset-bot bot commented Mar 9, 2026

⚠️ No Changeset found

Latest commit: 755eb62

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Member

@benjie benjie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for raising this PR! I've raised a number of comments on the content, let me know your thoughts 🙌 In particular I want to avoid implying that loadOne/loadMany are "custom" steps or that lambda is the only/main step, so I've been a bit tighter around the wording there.

Consider breaking this PR up into smaller pieces; then we can get some of the more obvious parts merged whilst the more subtle pieces continue to be worked on.

Comment on lines +78 to +86
if (isDev && !fn.name && !warnedCallbacks.has(fn)) {
warnedCallbacks.add(fn);
console.warn(
`lambda() was called with an anonymous (inline) callback function. ` +
`This prevents deduplication. Define the callback at file scope or ` +
`give it a name for better optimization. ` +
`See: https://grafast.org/grafast/standard-steps/lambda#define-callback-in-top-scope`,
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super keen on this; I'd rather handle it with a lint rule later. The main reason is that just not having a name is not sufficient to know that it was inline; for example we recommend EXPORTABLE(...) usage everywhere; however:

const { EXPORTABLE } = require('graphile-export')
const TWO = 2
const addTwo = EXPORTABLE((TWO) => (n) => n + TWO, [TWO])
console.log(addTwo.name)

This'll output '' since there's no name for this function.

Comment on lines +13 to +65
## Extract arguments deeply

When accessing nested argument values, prefer extracting the leaf value directly
rather than extracting an intermediate object and then pulling values from it.
This gives Gra*fast* more information about what you actually need, which
enables better optimization.

```graphql
input UserFilter {
author: String
publishedAfter: Int
}

type Query {
bookCount(search: String, filter: UserFilter): Int!
}
```

### Don't: shallow extraction then transform

```ts
function bookCount_plan($parent, fieldArgs) {
const $filter = fieldArgs.getRaw("filter");
// ✘ Creates an unnecessary intermediate lambda step
const $author = lambda($filter, (f) => f?.author);
// ...
}
```

### Do: deep extraction directly

```ts
function bookCount_plan($parent, fieldArgs) {
// ✔ One step, directly optimizable
const $author = fieldArgs.getRaw(["filter", "author"]);
const $publishedAfter = fieldArgs.getRaw(["filter", "publishedAfter"]);
// ...
}
```

You can also use the `$`-prefixed shortcut for the same result:

```ts
function bookCount_plan($parent, fieldArgs) {
const { $search, $filter } = fieldArgs;
const { $author, $publishedAfter } = $filter;
// ...
}
```

Both `.getRaw()` with a path array and the `$`-prefixed destructuring give
Gra*fast* direct visibility into exactly which leaf values you need, allowing it
to skip unnecessary work and optimize the plan more aggressively.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a major concern before the "eliminate eval" logic came in. It's no longer as important, but probably worth keeping as a best practice anyway - it may become more significant again in future optimizations.

Comment on lines +83 to +98
## When to use something else

Before reaching for `lambda`, consider whether a better tool exists:

- **I/O or async work** → use [`loadOne()`](./loadOne.md) or
[`loadMany()`](./loadMany.md) which support batching
- **Non-trivial transforms that appear in multiple fields** → create a
[custom step class](../step-classes.mdx) with `deduplicate()` support
- **Side effects** → use [`sideEffect()`](/grafast/standard-steps/sideEffect)

`lambda` is best reserved for trivial, synchronous, pure transforms such as
string concatenation or simple arithmetic.

See [Plan resolver best practices](../plan-resolvers/best-practices.md) for
more guidance.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have two notes on this page warning about lambda not batching; perhaps this should be folded into those?

@github-project-automation github-project-automation bot moved this from 🌳 Triage to 🌱 In Progress in V5.0.0 Mar 10, 2026
Co-authored-by: Benjie <benjie@jemjie.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🌱 In Progress

Development

Successfully merging this pull request may close these issues.

2 participants